Skip to main content

copp\copp\copp3/
interpolation.rs

1//! Interpolation and profile-conversion utilities for third-order path parameterization.
2//!
3//! # Method identity
4//! This module serves both:
5//! - **Time-Optimal Path Parameterization (TOPP3)** workflows,
6//! - **Convex-Objective Path Parameterization (COPP3)** workflows.
7//!
8//! # Scope
9//! This module provides deterministic conversions between:
10//! - node profiles `a(s) = \dot{s}^2` and `b(s) = \ddot{s}` sampled on stations,
11//! - time mapping `t(s)`,
12//! - inverse sampling `s(t)`.
13//!
14//! # Conventions
15//! - Path grid uses station samples `s[0..=n]`.
16//! - Both `a` and `b` are node-based in TOPP3/COPP3 (`a.len() == b.len() == s.len()`).
17//! - `num_stationary = (head, tail)` indicates stationary boundary counts at start/end.
18//! - Ordinary intervals normally use the left polynomial anchor. The last
19//!   ordinary interval uses the right boundary anchor; an exact zero endpoint
20//!   takes priority (left if both endpoints are zero). Forward/inverse time
21//!   and profile SOC checks share this convention. Node pairs must already
22//!   satisfy the discrete dynamics up to their constructor's own rounding.
23//!
24//! # Example
25//! The example below converts a third-order profile from station samples to
26//! cumulative time and then samples the inverse map `s(t)`.
27//!
28//! ```rust
29//! # fn main() -> Result<(), copp::diag::CoppError> {
30//! use copp::InterpolationMode;
31//! use copp::solver::topp3_lp::{s_to_t_topp3, t_to_s_topp3, Topp3Profile};
32//!
33//! let s = [0.0, 0.5, 1.0];
34//! let profile = Topp3Profile::new(
35//!     vec![1.0, 1.0, 1.0],
36//!     vec![0.0, 0.0, 0.0],
37//!     (0, 0),
38//! );
39//!
40//! let (_t_final, t_s) = s_to_t_topp3(&s, profile.as_parts(), 0.0)?;
41//! let s_t = t_to_s_topp3(
42//!     &s,
43//!     profile.as_parts(),
44//!     &t_s,
45//!     InterpolationMode::UniformTimeGrid(0.0, 0.25, true),
46//! )?;
47//!
48//! assert_eq!(s_t.first().copied(), Some(0.0));
49//! assert_eq!(s_t.last().copied(), Some(1.0));
50//! # Ok(())
51//! # }
52//! ```
53
54use crate::copp::InterpolationMode;
55use crate::copp::copp3::exact_interval::{
56    IntervalANonnegativeClassification, certify_interval_endpoint_ln,
57    classify_interval_a_nonnegative, exact_interval_negative_m_abs_ln,
58    exact_interval_nonnegative_m_ln, exact_interval_positive_d_ln,
59    profile_interval_uses_right_anchor,
60};
61use crate::copp::copp3::{Topp3ProfileMut, Topp3ProfileRef};
62use crate::diag::{
63    CoppError, check_input_len_at_least, check_input_len_equal, check_input_non_negative,
64    check_input_not_empty, check_input_not_nan_infinite, check_input_slice_non_negative,
65    check_input_slice_not_nan_infinite, check_input_strictly_increasing,
66};
67use crate::math::numerical::{EPS_ZERO, solve_2x2};
68use itertools::izip;
69use nalgebra::ComplexField;
70
71/// Taylor threshold for the natural constant-jerk inverse map.
72const TAYLOR_INVERSE_CONSTANT_JERK: f64 = 1E-3;
73/// Taylor threshold for the natural constant-jerk interval-time map.
74const TAYLOR_INTERVAL_TIME_Z: f64 = 1E-4;
75/// The direct `z` expression is not a reliable distance to one inside this
76/// envelope. This only selects the exact-sign cold path.
77const INTERVAL_TIME_NEAR_ONE_DELTA: f64 = 64.0 * f64::EPSILON;
78
79/// Compute cumulative time profile `t(s)` from a TOPP3/COPP3 profile.
80///
81/// # Semantics
82/// - `t_s[i]` is the time at station `s[i]`.
83/// - initial condition is `t_s[0] = t0`.
84/// - returns `(t_final, t_s)` where `t_final == *t_s.last().unwrap()`.
85///
86/// # Input contract
87/// - valid when `s.len() >= 2 + profile.2.0 + profile.2.1`;
88/// - requires `profile.0.len() == s.len()` and `profile.1.len() == s.len()`;
89/// - all inputs must contain only finite values;
90/// - `s` must be strictly increasing.
91///
92/// # Returns
93/// Returns `(t_final, t_s)` where `t_s[i]` is cumulative time at `s[i]`.
94///
95/// # Errors
96/// Returns [`CoppError::InvalidInput`](crate::diag::CoppError::InvalidInput) when dimensions, stationary counts,
97/// monotonicity, positivity, or numeric finiteness requirements are violated.
98///
99/// # Contract
100/// - `t_s.len() == s.len()` on valid input.
101/// - `t_s[0] == t0` on valid input.
102pub fn s_to_t_topp3(
103    s: &[f64],
104    profile: Topp3ProfileRef<'_>,
105    t0: f64,
106) -> Result<(f64, Vec<f64>), CoppError> {
107    check_topp3_sab("s_to_t_topp3", s, profile)?;
108    check_input_not_nan_infinite("s_to_t_topp3", "t0", t0)?;
109    let (a, b, num_stationary) = profile;
110    let mut t_s = Vec::<f64>::with_capacity(s.len()); // t_s[i] = t(s[i]), begin from t0
111    let mut t_prev = t0;
112    let n = s.len() - 1;
113    t_s.push(t_prev);
114    if num_stationary.0 > 0 {
115        let s0 = s.first().unwrap();
116        t_s.resize(1 + num_stationary.0, t_prev);
117        for (t_curr, a_curr, s_curr) in izip!(t_s.iter_mut(), a.iter(), s.iter()).skip(1) {
118            *t_curr += 3.0 * (s_curr - s0) / a_curr.sqrt();
119        }
120        t_prev = *t_s.last().unwrap();
121    }
122    for (id, (s_pair, b_pair, a_pair)) in izip!(s.windows(2), b.windows(2), a.windows(2))
123        .enumerate()
124        .skip(num_stationary.0)
125        .take(n - num_stationary.0 - num_stationary.1)
126    {
127        t_prev += integral_profile_interval(
128            s_pair[1] - s_pair[0],
129            a_pair,
130            b_pair,
131            id + 1 == n - num_stationary.1,
132        );
133        t_s.push(t_prev);
134    }
135    if num_stationary.1 > 0 {
136        let s_final = s.last().unwrap();
137        let t_final =
138            t_prev + 3.0 * (s_final - s[n - num_stationary.1]) / a[n - num_stationary.1].sqrt();
139        t_s.resize(s.len(), t_final);
140        if num_stationary.1 > 1 {
141            for (t_curr, a_curr, s_curr) in izip!(t_s.iter_mut(), a.iter(), s.iter())
142                .rev()
143                .skip(1)
144                .take(num_stationary.1 - 1)
145            {
146                *t_curr += 3.0 * (s_curr - s_final) / a_curr.sqrt();
147            }
148        }
149    }
150
151    let t_final = *t_s.last().unwrap();
152    // Hot path: branchless reduction over the whole profile. `&` does not
153    // short-circuit, so this stays a straight-line loop that can vectorize;
154    // `t_final` is `t_s.last()` and is therefore already covered.
155    let all_finite = t_s.iter().fold(true, |acc, value| acc & value.is_finite());
156    if !all_finite {
157        // Cold path: an error is being returned anyway, so a second, scalar
158        // pass to locate the offending station costs nothing that matters.
159        // The natural interval kernel diverges when a station has
160        // `a == b == 0`, and
161        // returns NaN when `a(s)` goes negative between two stations, so the
162        // neighbouring `(a, b)` pair is what identifies the cause.
163        let index = t_s
164            .iter()
165            .position(|value| !value.is_finite())
166            .unwrap_or(t_s.len() - 1);
167        // `t_s[k+1] = t_s[k] + integral(a[k], b[k], b[k+1])`, so the interval
168        // that diverged is `[index-1, index]`; the stationary head instead uses
169        // `a[index]` directly. Report a window that covers both.
170        let lo = index.saturating_sub(1);
171        let hi = (index + 1).min(a.len() - 1);
172        let window = (lo..=hi)
173            .map(|j| {
174                format!(
175                    "  [{j}] a = {:.6e}, b = {:.6e}, s = {:.6e}",
176                    a[j], b[j], s[j]
177                )
178            })
179            .collect::<Vec<_>>()
180            .join("\n");
181        return Err(CoppError::InvalidInput(
182            "s_to_t_topp3".into(),
183            format!(
184                "computed time profile contains NaN or infinity: first at index {index} \
185                 (t = {}), num_stationary = {num_stationary:?}\n{window}",
186                t_s[index]
187            ),
188        ));
189    }
190    check_input_strictly_increasing("s_to_t_topp3", "t_s", &t_s)?;
191    Ok((t_final, t_s))
192}
193
194/// Interpolate inverse mapping `s(t)` from a TOPP3/COPP3 profile and sampled `t(s)`.
195///
196/// # Modes
197/// - [`UniformTimeGrid`](crate::InterpolationMode::UniformTimeGrid)`(t0, dt, include_final)`: generate uniform time samples;
198/// - `NonUniformTimeGrid(t_sample)`: use caller-provided increasing samples.
199///
200/// # Input contract
201/// - requires `s.len() >= 2`, profile slice lengths equal to `s.len()`, and `t_s.len() == s.len()`;
202/// - requires `t_s` strictly increasing;
203/// - all profile and time-grid values must be finite.
204///
205/// # Output semantics
206/// - output length matches requested sample count in each mode;
207/// - for out-of-range time samples, output entries are `NaN`.
208///
209/// # Returns
210/// Returns sampled `s(t)` values under the requested interpolation `mode`.
211///
212/// # Errors
213/// Returns [`CoppError::InvalidInput`](crate::diag::CoppError::InvalidInput) when dimensions, stationary counts,
214/// monotonicity, positivity, or numeric finiteness requirements are violated.
215///
216/// # Contract
217/// - preserves caller time-sample ordering.
218/// - malformed input is reported as [`CoppError::InvalidInput`](crate::diag::CoppError::InvalidInput).
219pub fn t_to_s_topp3(
220    s: &[f64],
221    profile: Topp3ProfileRef<'_>,
222    t_s: &[f64],
223    mode: InterpolationMode<'_>,
224) -> Result<Vec<f64>, CoppError> {
225    check_topp3_sab("t_to_s_topp3", s, profile)?;
226    check_input_len_equal(
227        "t_to_s_topp3",
228        "`t_s.len()`",
229        t_s.len(),
230        "`s.len()`",
231        s.len(),
232    )?;
233    check_input_slice_not_nan_infinite("t_to_s_topp3", "t_s", t_s)?;
234    check_input_strictly_increasing("t_to_s_topp3", "t_s", t_s)?;
235    match mode {
236        InterpolationMode::UniformTimeGrid(t0, dt, include_final) => {
237            check_input_not_nan_infinite("t_to_s_topp3", "t0", t0)?;
238            check_input_not_nan_infinite("t_to_s_topp3", "dt", dt)?;
239            if dt <= 0.0 {
240                return Err(CoppError::InvalidInput(
241                    "t_to_s_topp3".into(),
242                    format!("`dt` = {dt} must be positive"),
243                ));
244            }
245            // num_t * dt + t0 <= t_final
246            let num_t = ((t_s.last().unwrap() - t0) / dt).floor() as usize;
247            let mut s_t = t_to_s_topp3_core(
248                s,
249                profile,
250                t_s,
251                (0..num_t).map(|i| t0 + i as f64 * dt),
252                num_t,
253            );
254            if include_final {
255                let flag = if s_t.is_empty() {
256                    t0 <= *t_s.last().unwrap()
257                } else {
258                    *s_t.last().unwrap() < *s.last().unwrap()
259                };
260                if flag {
261                    s_t.push(*s.last().unwrap());
262                }
263            }
264            Ok(s_t)
265        }
266        InterpolationMode::NonUniformTimeGrid(t_sample) => {
267            check_input_not_empty("t_to_s_topp3", "`t_sample`", t_sample.len())?;
268            check_input_slice_not_nan_infinite("t_to_s_topp3", "t_sample", t_sample)?;
269            check_input_strictly_increasing("t_to_s_topp3", "t_sample", t_sample)?;
270            Ok(t_to_s_topp3_core(
271                s,
272                profile,
273                t_s,
274                t_sample.iter().cloned(),
275                t_sample.len(),
276            ))
277        }
278    }
279}
280
281/// Core inverse interpolation kernel for [`t_to_s_topp3`](crate::solver::topp3_socp::t_to_s_topp3).
282///
283/// The public wrapper validates dimensions, finiteness, station ordering, and
284/// sample ordering before calling this routine. This core then walks the time
285/// samples once and maps each sample into the corresponding station interval.
286fn t_to_s_topp3_core(
287    s: &[f64],
288    profile: Topp3ProfileRef<'_>,
289    t_s: &[f64],
290    mut t_sample: impl Iterator<Item = f64>,
291    len_t_sample: usize,
292) -> Vec<f64> {
293    let (a, b, num_stationary) = profile;
294    // Map t to s
295    let &t_start = t_s.first().unwrap();
296    let &t_final = t_s.last().unwrap();
297    let mut s_t = Vec::<f64>::with_capacity(len_t_sample + 1); // s_t[i] = s(t[i])
298    let Some(mut t_curr) = t_sample.next() else {
299        return vec![];
300    };
301    while t_curr < t_start {
302        s_t.push(f64::NAN);
303        let Some(t) = t_sample.next() else {
304            return s_t;
305        };
306        t_curr = t;
307    }
308
309    if num_stationary.0 > 0 {
310        let s0 = s.first().unwrap();
311        let a_stationary = a[num_stationary.0];
312        let t_stationary = t_s[num_stationary.0];
313        let d3u_over_6 =
314            a_stationary.sqrt() * a_stationary / (27.0 * (s[num_stationary.0] - s0).powi(2));
315        while t_curr <= t_stationary {
316            s_t.push(s0 + d3u_over_6 * (t_curr - t_start).powi(3));
317            let Some(t) = t_sample.next() else {
318                return s_t;
319            };
320            t_curr = t;
321        }
322    }
323
324    for (id, (s_pair, a_pair, b_pair, t_pair)) in
325        izip!(s.windows(2), a.windows(2), b.windows(2), t_s.windows(2))
326            .enumerate()
327            .skip(num_stationary.0)
328            .take(s.len() - num_stationary.0 - num_stationary.1 - 1)
329    {
330        let h = s_pair[1] - s_pair[0];
331        let reverse = profile_interval_uses_right_anchor(
332            a_pair[0],
333            a_pair[1],
334            id + 1 == s.len() - num_stationary.1 - 1,
335        );
336        while t_curr <= t_pair[1] {
337            // Keep the sampled station exact when the query is an existing
338            // time knot; the analytic inverse is needed only in the interior.
339            let position = if t_curr == t_pair[1] {
340                s_pair[1]
341            } else if t_curr == t_pair[0] {
342                s_pair[0]
343            } else if reverse {
344                s_pair[1]
345                    - inverse_constant_jerk_interval(
346                        h,
347                        a_pair[1],
348                        -b_pair[1],
349                        -b_pair[0],
350                        t_pair[1] - t_curr,
351                    )
352            } else {
353                s_pair[0]
354                    + inverse_constant_jerk_interval(
355                        h,
356                        a_pair[0],
357                        b_pair[0],
358                        b_pair[1],
359                        t_curr - t_pair[0],
360                    )
361            };
362            s_t.push(position);
363            let Some(t) = t_sample.next() else {
364                return s_t;
365            };
366            t_curr = t;
367        }
368    }
369
370    if num_stationary.1 > 0 {
371        let s_final = s.last().unwrap();
372        let a_stationary = a[s.len() - num_stationary.1 - 1];
373        let d3u_over_6 = a_stationary.sqrt() * a_stationary
374            / (27.0 * (s_final - s[s.len() - num_stationary.1 - 1]).powi(2));
375        while t_curr <= t_final {
376            s_t.push(s_final + d3u_over_6 * (t_curr - t_final).powi(3));
377            let Some(t) = t_sample.next() else {
378                return s_t;
379            };
380            t_curr = t;
381        }
382    }
383
384    s_t.push(f64::NAN);
385    while t_sample.next().is_some() {
386        s_t.push(f64::NAN);
387    }
388    s_t
389}
390
391/// Profile-level anchor convention matches the final SOC scan. An exact
392/// stationary endpoint without its own stationary block always diverges;
393/// changing the polynomial's anchor must not erase that double zero.
394#[inline]
395fn integral_profile_interval(h: f64, a: &[f64], b: &[f64], last_ordinary: bool) -> f64 {
396    profile_interval_time(h, a[0], b[0], a[1], b[1], last_ordinary)
397}
398
399/// Evaluate one delivered-profile interval with its original endpoint-anchor
400/// classification. Callers that validate a subdomain must preserve the
401/// full-profile `last_ordinary` flag instead of slicing and reclassifying it.
402#[inline]
403pub(crate) fn profile_interval_time(
404    h: f64,
405    a_left: f64,
406    b_left: f64,
407    a_right: f64,
408    b_right: f64,
409    last_ordinary: bool,
410) -> f64 {
411    if (a_left == 0.0 && b_left == 0.0) || (a_right == 0.0 && b_right == 0.0) {
412        return f64::INFINITY;
413    }
414    if profile_interval_uses_right_anchor(a_left, a_right, last_ordinary) {
415        integral_constant_jerk_interval(h, a_right, -b_right, -b_left)
416    } else {
417        integral_constant_jerk_interval(h, a_left, b_left, b_right)
418    }
419}
420
421/// Stable `psi(z)` in the endpoint form of the natural constant-jerk time
422/// integral.  Only the value is needed here; no derivatives are carried.
423#[inline(always)]
424fn interval_time_psi(z: f64) -> f64 {
425    if z.abs() < TAYLOR_INTERVAL_TIME_Z {
426        z.mul_add(z.mul_add(z.mul_add(1.0 / 7.0, 1.0 / 5.0), 1.0 / 3.0), 1.0)
427    } else {
428        let root = z.abs().sqrt();
429        if z > 0.0 {
430            root.atanh() / root
431        } else {
432            root.atan() / root
433        }
434    }
435}
436
437#[inline(always)]
438fn logaddexp(lhs: f64, rhs: f64) -> f64 {
439    if lhs == f64::INFINITY || rhs == f64::INFINITY {
440        return f64::INFINITY;
441    }
442    if lhs == f64::NEG_INFINITY {
443        return rhs;
444    }
445    if rhs == f64::NEG_INFINITY {
446        return lhs;
447    }
448    let high = lhs.max(rhs);
449    let low = lhs.min(rhs);
450    high + (low - high).exp().ln_1p()
451}
452
453#[inline(always)]
454fn interval_time_scaled_z(h: f64, b: f64, u: f64, sqrt_sum: f64) -> (f64, f64) {
455    if u == b {
456        return (0.0, f64::NEG_INFINITY);
457    }
458    let difference = u - b;
459    let ln_abs_difference = if difference.is_finite() {
460        difference.abs().ln()
461    } else {
462        logaddexp(u.abs().ln(), b.abs().ln())
463    };
464    let ln_magnitude = h.ln() + ln_abs_difference - 2.0 * sqrt_sum.ln();
465    let magnitude = ln_magnitude.exp();
466    (if u > b { magnitude } else { -magnitude }, ln_magnitude)
467}
468
469#[inline(always)]
470fn interval_time_scaled_value(h: f64, sqrt_sum: f64, d: f64, psi: f64) -> f64 {
471    if d.is_finite() && d > 0.0 {
472        let direct = d * (2.0 * psi);
473        if direct.is_finite() && direct > 0.0 {
474            return direct;
475        }
476    }
477    (std::f64::consts::LN_2 + h.ln() - sqrt_sum.ln() + psi.ln()).exp()
478}
479
480#[inline(always)]
481fn interval_time_psi_near_one(ln_delta: f64) -> f64 {
482    debug_assert!(ln_delta < 0.0);
483    let z = (-ln_delta.exp_m1()).clamp(0.0, 1.0);
484    let root = z.sqrt();
485    (root.ln_1p() - 0.5 * ln_delta) / root
486}
487
488/// Traverse one ordinary interval under the natural, unmaterialized model
489///
490/// `a(x) = a + 2*b*x + (u-b)*x^2/h`, `0 <= x <= h`.
491///
492/// The exact-sign classifier comes from the shared exact-interval kernel.
493/// In particular, finiteness is never decided from the
494/// different polynomial obtained by first rounding `(u-b)/h` and then using
495/// that rounded coefficient in a quadratic evaluation.
496#[inline]
497fn integral_constant_jerk_interval(h: f64, a: f64, b: f64, u: f64) -> f64 {
498    let Some(classified) = classify_interval_a_nonnegative(h, a, b, u) else {
499        return f64::NAN;
500    };
501    let m = h.mul_add(b, a);
502    let a_next = h.mul_add(u, m);
503    let exact_endpoint_ln = match certify_interval_endpoint_ln(h, a, b, u, m, a_next) {
504        Ok(value) => value,
505        Err(()) => return f64::NAN,
506    };
507    let sqrt_a = a.sqrt();
508    let sqrt_next = match exact_endpoint_ln {
509        Some(ln_endpoint) if ln_endpoint.is_finite() => (0.5 * ln_endpoint).exp(),
510        Some(_) => 0.0,
511        None => a_next.sqrt(),
512    };
513    let curvature = (u - b) / h;
514
515    // Two simple zero endpoints are integrable; a double zero is not.
516    if (a == 0.0 && b > 0.0 && u == -b) || (a == 0.0 && sqrt_next == 0.0 && curvature < 0.0) {
517        return std::f64::consts::PI / (-curvature).sqrt();
518    }
519    if (a == 0.0 && b == 0.0 && u >= 0.0) || (sqrt_next == 0.0 && u == 0.0 && b <= 0.0) {
520        return f64::INFINITY;
521    }
522    if matches!(classified, IntervalANonnegativeClassification::InteriorZero) {
523        return f64::INFINITY;
524    }
525
526    let sqrt_sum = sqrt_a + sqrt_next;
527    let d = h / sqrt_sum;
528    let d2 = d * d;
529    let raw_z = curvature * d2;
530    let unsafe_scale =
531        d == 0.0 || !d.is_finite() || d2 == 0.0 || !d2.is_finite() || !raw_z.is_finite();
532    let (z, ln_abs_z) = if unsafe_scale {
533        interval_time_scaled_z(h, b, u, sqrt_sum)
534    } else {
535        (raw_z, raw_z.abs().ln())
536    };
537    let near_singularity = if unsafe_scale {
538        z > 0.5
539    } else {
540        z >= 1.0 - INTERVAL_TIME_NEAR_ONE_DELTA
541    };
542
543    if near_singularity {
544        if !d.is_finite() {
545            return f64::INFINITY;
546        }
547        let ln_delta = match classified {
548            IntervalANonnegativeClassification::InteriorPositive => {
549                let Some(ln_d) = exact_interval_positive_d_ln(h, a, b, u) else {
550                    return f64::NAN;
551                };
552                let Some(ln_abs_m) = exact_interval_negative_m_abs_ln(h, a, b) else {
553                    return f64::NAN;
554                };
555                let ln_pq = sqrt_a.ln() + sqrt_next.ln();
556                std::f64::consts::LN_2 + h.ln() + ln_d
557                    - logaddexp(ln_pq, ln_abs_m)
558                    - 2.0 * sqrt_sum.ln()
559            }
560            IntervalANonnegativeClassification::Endpoint => {
561                let Some(ln_m) = exact_interval_nonnegative_m_ln(h, a, b) else {
562                    return f64::NAN;
563                };
564                let ln_pq = if sqrt_a == 0.0 || sqrt_next == 0.0 {
565                    f64::NEG_INFINITY
566                } else {
567                    sqrt_a.ln() + sqrt_next.ln()
568                };
569                std::f64::consts::LN_2 + logaddexp(ln_m, ln_pq) - 2.0 * sqrt_sum.ln()
570            }
571            IntervalANonnegativeClassification::InteriorZero => return f64::INFINITY,
572        };
573        if ln_delta.is_finite() && ln_delta < 0.0 {
574            let psi = interval_time_psi_near_one(ln_delta);
575            return interval_time_scaled_value(h, sqrt_sum, d, psi);
576        }
577        if !z.is_finite() || z >= 1.0 {
578            return f64::INFINITY;
579        }
580    }
581
582    if unsafe_scale && z.is_sign_negative() && ln_abs_z > 0.5 * f64::MAX.ln() {
583        let ln_psi = std::f64::consts::FRAC_PI_2.ln() - 0.5 * ln_abs_z;
584        return (std::f64::consts::LN_2 + h.ln() - sqrt_sum.ln() + ln_psi).exp();
585    }
586
587    let psi = interval_time_psi(z);
588    if unsafe_scale || z > 0.5 {
589        interval_time_scaled_value(h, sqrt_sum, d, psi)
590    } else {
591        2.0 * d * psi
592    }
593}
594
595/// Invert the same natural ordinary-interval model used by
596/// [`integral_constant_jerk_interval`].  The rounded dimensionless `eps` below
597/// is only an evaluation parameter for the analytic solution; no second
598/// rounded-coefficient polynomial is used for feasibility or interval time.
599#[inline(always)]
600fn inverse_constant_jerk_interval(h: f64, a: f64, b: f64, u: f64, dt: f64) -> f64 {
601    if dt == 0.0 {
602        return 0.0;
603    }
604    let eps = ((u - b) / h) * dt * dt;
605    let sqrt_a = a.sqrt();
606    let half_b_dt = 0.5 * b * dt;
607
608    if eps.abs() < TAYLOR_INVERSE_CONSTANT_JERK {
609        let first = eps.mul_add(
610            eps.mul_add(eps.mul_add(1.0 / 5040.0, 1.0 / 120.0), 1.0 / 6.0),
611            1.0,
612        );
613        let second = eps.mul_add(
614            eps.mul_add(eps.mul_add(1.0 / 20160.0, 1.0 / 360.0), 1.0 / 12.0),
615            1.0,
616        );
617        dt * sqrt_a.mul_add(first, half_b_dt * second)
618    } else if eps > 0.0 {
619        let k = eps.sqrt();
620        let sinh_half_over_k = (0.5 * k).sinh() / k;
621        dt * sqrt_a.mul_add(
622            k.sinh() / k,
623            2.0 * b * dt * sinh_half_over_k * sinh_half_over_k,
624        )
625    } else {
626        let k = (-eps).sqrt();
627        let sin_half_over_k = (0.5 * k).sin() / k;
628        dt * sqrt_a.mul_add(
629            k.sin() / k,
630            2.0 * b * dt * sin_half_over_k * sin_half_over_k,
631        )
632    }
633}
634
635/// Post-process a mutable `(a, b)` profile so that interpolated `a(s)` stays strictly positive per interval.
636///
637/// This is a numerical safety utility for downstream timing integration on
638/// profiles that may be very close to zero due to finite precision.
639///
640/// # Returns
641/// Returns `true` when in-place adjustment succeeds, otherwise `false`.
642///
643/// # Errors
644/// Returns [`CoppError::InvalidInput`](crate::diag::CoppError::InvalidInput) when dimensions, station ordering, profile
645/// positivity, stationary counts, or numeric finiteness requirements are violated.
646///
647/// # Contract
648/// - requires `a.len() == b.len() == s.len()` and `s.len() >= 4`;
649/// - requires endpoint `a` values to be nonnegative.
650pub fn force_positive_a(
651    profile: Topp3ProfileMut<'_>,
652    s: &[f64],
653    a_min: f64,
654) -> Result<bool, CoppError> {
655    let (a, b, num_stationary) = profile;
656    let n = s.len();
657    if a.len() != n || b.len() != n {
658        return Err(CoppError::InvalidInput(
659            "force_positive_a".into(),
660            format!(
661                "`a.len()` = {} and `b.len()` = {} must equal `s.len()` = {}",
662                a.len(),
663                b.len(),
664                n
665            ),
666        ));
667    }
668    if n < 4 {
669        return Err(CoppError::InvalidInput(
670            "force_positive_a".into(),
671            format!("`s.len()` = {n} must be at least 4"),
672        ));
673    }
674    check_stationary_counts("force_positive_a", n, num_stationary)?;
675    check_input_slice_not_nan_infinite("force_positive_a", "s", s)?;
676    check_input_slice_non_negative("force_positive_a", "a", a)?;
677    check_input_slice_not_nan_infinite("force_positive_a", "b", b)?;
678    check_input_non_negative("force_positive_a", "a_min", a_min)?;
679    check_input_strictly_increasing("force_positive_a", "s", s)?;
680    // Now we have a(s[i]) >= 0, and we would like to modify a(s) > 0 for s in (s[i], s[i+1]) if a(s) can be negative for some s in (s[i], s[i+1]).
681    let mut flag_succeed = true;
682    for i in (num_stationary.0 + 1)..(n - 2 - num_stationary.1) {
683        // Consider a[i-1], a[i], a[i+1], a[i+2]
684        let b1 = b[i];
685        let b2 = b[i + 1];
686        if b1 < 0.0 && b2 > 0.0 {
687            // a(s) = a[i] + 2 * b[i] * (s - s[i]) + (b[i+1] - b[i]) / ds1 * (s - s[i])^2
688            // b[i] ^ 2 < a[i] * (b[i+1] - b[i]) / ds1 should hold
689            // b[i] ^ 2 * ds1 < a[i] * (b[i+1] - b[i]) should hold
690            let s1 = s[i];
691            let s2 = s[i + 1];
692            let ds1 = s2 - s1;
693            let a1 = a[i];
694            let amin = a_min.max(a1.min(a[i + 1]));
695            let amin = if amin > 10.0 * EPS_ZERO {
696                0.1 * amin
697            } else if amin > EPS_ZERO {
698                EPS_ZERO
699            } else {
700                amin
701            };
702            let da = a1 - amin;
703            let db = b2 - b1;
704            if b1 * b1 * ds1 >= da * db {
705                // a(s) <= 0 holds in (s[i], s[i+1])
706                // We add c0 on (s[i-1],s[i+2]), c1 on (s[i],s[i+2]), and c2 on (s[i+1],s[i+2])
707                // x[i-1] and x[i+2] should keep the same.
708                // (i) --- c0*(s[i+2] - s[i-1]) + c1*(s[i+2] - s[i]) + c2*(s[i+2] - s[i+1]) == 0
709                // (ii) --- c0*(s[i+2] - s[i-1])^2 + c1*(s[i+2] - s[i])^2 + c2*(s[i+2] - s[i+1])^2 == 0
710                let s0 = s[i - 1];
711                let s3 = s[i + 2];
712                let delta_s_end = (s3 - s0, s3 - s1, s3 - s2);
713                let coeff = match solve_2x2(
714                    (
715                        (delta_s_end.1, delta_s_end.2),
716                        (delta_s_end.1 * delta_s_end.1, delta_s_end.2 * delta_s_end.2),
717                    ),
718                    (-delta_s_end.0, -delta_s_end.0 * delta_s_end.0),
719                ) {
720                    Some(coeff) => {
721                        // A*[c1;c2] = b*c0
722                        coeff
723                    }
724                    None => {
725                        crate::verbosity_log!(
726                            crate::diag::Verbosity::Debug,
727                            "coeff is None? A = {:?}, b = {:?}",
728                            (
729                                (delta_s_end.1, delta_s_end.2),
730                                (delta_s_end.1 * delta_s_end.1, delta_s_end.2 * delta_s_end.2)
731                            ),
732                            (-delta_s_end.0, -delta_s_end.0 * delta_s_end.0)
733                        );
734                        flag_succeed = false;
735                        continue;
736                    }
737                };
738                // c1 = coeff.0 * c0, c2 = coeff.1 * c0
739                // Changes: a[i] += c0 * (s1-s0)^2, b[i] += c0 * (s1-s0), b[i+1] += c0 * (s2-s0) + c1 * (s2-s1)
740                let ds0 = s1 - s0;
741                let coeff_c = (ds0 * ds0, ds0, ds0 + ds1 * (1.0 + coeff.0));
742                // Changes: a[i] += c0 * coeff_c.0, b[i] += c0 * coeff_c.1, b[i+1] += c0 * coeff_c.2
743                // We hope that a(s) = a[i] + 2 * b[i] * (s - s[i]) + (b[i+1] - b[i]) / ds1 * (s - s[i])^2 >= amin holds in (s[i],s[i+1])
744                // b[i] ^ 2 * ds1 == (a[i] - amin) * (b[i+1] - b[i]) should hold for new ones.
745                // For old ones: (b[i] + coeff_c.1 * c0) ^ 2 * ds1 == (a[i] - amin + coeff_c.0 * c0) * (b[i+1] - b[i] + (coeff_c.2-coeff_c.1) * c0). Now solve c0.
746                // (coeff_c.1^2 * c0^2 + 2 * b1 * coeff_c.1 * c0 + b1 ^ 2) * ds1 == coeff_c.0 * (coeff_c.2-coeff_c.1) * c0^2 + (da * (coeff_c.2-coeff_c.1) + coeff_c.0 * db) * c0 + da * db
747                // (coeff_c.1^2 * ds1 - coeff_c.0 * (coeff_c.2-coeff_c.1)) * c0^2 + (2 * b1 * coeff_c.1 * ds1 - da * (coeff_c.2-coeff_c.1) - coeff_c.0 * db) * c0 + (b1 * b1 * ds1 - da * db) == 0
748                let coeff_solve = (
749                    coeff_c.1 * coeff_c.1 * ds1 - coeff_c.0 * (coeff_c.2 - coeff_c.1),
750                    2.0 * b1 * coeff_c.1 * ds1 - da * (coeff_c.2 - coeff_c.1) - coeff_c.0 * db,
751                    b1 * b1 * ds1 - da * db,
752                );
753                let norm = coeff_solve.0.abs() + coeff_solve.1.abs() + coeff_solve.2.abs();
754                if norm < EPS_ZERO {
755                    crate::verbosity_log!(
756                        crate::diag::Verbosity::Debug,
757                        "norm = {norm} < EPS_ZERO, coeff_solve = {coeff_solve:.8?}"
758                    );
759                    flag_succeed = false;
760                    continue;
761                }
762                let norm_inv = 1.0 / norm;
763                let coeff_solve = (
764                    coeff_solve.0 * norm_inv,
765                    coeff_solve.1 * norm_inv,
766                    coeff_solve.2 * norm_inv,
767                );
768                // coeff_solve.0 * c0^2 + coeff_solve.1 * c0 + coeff_solve.2 == 0
769                let c0 = if coeff_solve.0.abs() > EPS_ZERO {
770                    // Use quadratic formula to solve for c0
771                    let discriminant =
772                        coeff_solve.1 * coeff_solve.1 - 4.0 * coeff_solve.0 * coeff_solve.2;
773                    if discriminant < 0.0 {
774                        if coeff_c.1.abs() > EPS_ZERO && coeff_c.2.abs() > EPS_ZERO {
775                            (-b1 / coeff_c.1).min(b2 / coeff_c.2)
776                        } else if coeff_c.1.abs() > EPS_ZERO {
777                            -b1 / coeff_c.1
778                        } else if coeff_c.2.abs() > EPS_ZERO {
779                            b2 / coeff_c.2
780                        } else {
781                            crate::verbosity_log!(
782                                crate::diag::Verbosity::Debug,
783                                "discriminant = {discriminant:.8} < 0 for c0 (i={i}): coeff_solve = {coeff_solve:.8?}, coeff_c = {coeff_c:.8?}"
784                            );
785                            flag_succeed = false;
786                            continue;
787                        }
788                    } else {
789                        let sqrt_discriminant = discriminant.sqrt();
790                        // c0: (max, min)
791                        let c0 = if coeff_solve.0 > 0.0 {
792                            (
793                                (-coeff_solve.1 + sqrt_discriminant) / (2.0 * coeff_solve.0),
794                                (-coeff_solve.1 - sqrt_discriminant) / (2.0 * coeff_solve.0),
795                            )
796                        } else {
797                            (
798                                (-coeff_solve.1 - sqrt_discriminant) / (2.0 * coeff_solve.0),
799                                (-coeff_solve.1 + sqrt_discriminant) / (2.0 * coeff_solve.0),
800                            )
801                        };
802                        if c0.1 >= 0.0 { c0.1 } else { c0.0 }
803                    }
804                } else {
805                    // Linear case
806                    -coeff_solve.2 / coeff_solve.1
807                };
808                a[i] += coeff_c.0 * c0;
809                b[i] += coeff_c.1 * c0;
810                b[i + 1] += coeff_c.2 * c0;
811                a[i + 1] += (coeff_c.0 + (coeff_c.1 + coeff_c.2) * ds1) * c0;
812            }
813        }
814    }
815
816    Ok(flag_succeed)
817}
818
819/// Check the shared TOPP3 profile shape, station counts, and station ordering.
820///
821/// TOPP3/COPP3 interpolation uses node-based `a(s)` and `b(s)` profiles on the
822/// same grid, with optional stationary head/tail sections. This helper keeps
823/// those preconditions together before any timing integration is attempted.
824fn check_topp3_sab(
825    function_name: &str,
826    s: &[f64],
827    profile: Topp3ProfileRef<'_>,
828) -> Result<(), CoppError> {
829    let (a, b, num_stationary) = profile;
830    check_stationary_counts(function_name, s.len(), num_stationary)?;
831    if a.len() != s.len() || b.len() != s.len() {
832        return Err(CoppError::InvalidInput(
833            function_name.into(),
834            format!(
835                "`a.len()` = {} and `b.len()` = {} must equal `s.len()` = {}",
836                a.len(),
837                b.len(),
838                s.len()
839            ),
840        ));
841    }
842    check_input_slice_not_nan_infinite(function_name, "s", s)?;
843    check_input_slice_non_negative(function_name, "a", a)?;
844    check_input_slice_not_nan_infinite(function_name, "b", b)?;
845    check_input_strictly_increasing(function_name, "s", s)
846}
847
848/// Check that stationary head/tail counts leave at least one motion interval.
849///
850/// The minimum station count is `2 + num_stationary.0 + num_stationary.1`;
851/// checked arithmetic is used so pathological `usize` inputs are rejected as
852/// invalid input instead of overflowing.
853fn check_stationary_counts(
854    function_name: &str,
855    s_len: usize,
856    num_stationary: (usize, usize),
857) -> Result<(), CoppError> {
858    let Some(min_len) = num_stationary
859        .0
860        .checked_add(num_stationary.1)
861        .and_then(|sum| sum.checked_add(2))
862    else {
863        return Err(CoppError::InvalidInput(
864            function_name.into(),
865            "`num_stationary` overflowed while checking dimensions".into(),
866        ));
867    };
868    check_input_len_at_least(function_name, "`s.len()`", s_len, min_len)
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    fn assert_close(actual: f64, expected: f64, relative: f64) {
876        let scale = actual.abs().max(expected.abs()).max(1.0);
877        assert!(
878            (actual - expected).abs() <= relative * scale,
879            "actual={actual:.17e}, expected={expected:.17e}"
880        );
881    }
882
883    #[test]
884    fn profile_time_reverse_fixed_zero_round_trip() -> Result<(), CoppError> {
885        let s = [0.0_f64, 1.0];
886        let b = [-0.1_f64, -0.4];
887        let a = [(-1.0_f64).mul_add(b[0] + b[1], 0.0), 0.0];
888        assert!(integral_constant_jerk_interval(1.0, a[0], b[0], b[1]).is_nan());
889        let (total, knots) = s_to_t_topp3(&s, (&a, &b, (0, 0)), 0.0)?;
890        assert!(total.is_finite() && total > 0.0);
891        let queries = [0.0, 0.25 * total, 0.5 * total, 0.75 * total, total];
892        let positions = t_to_s_topp3(
893            &s,
894            (&a, &b, (0, 0)),
895            &knots,
896            InterpolationMode::NonUniformTimeGrid(&queries),
897        )?;
898        assert_eq!(positions[0], s[0]);
899        assert_eq!(positions[4], s[1]);
900        assert!(positions.windows(2).all(|pair| pair[0] < pair[1]));
901        for (&time, &position) in queries[1..4].iter().zip(&positions[1..4]) {
902            let distance = 1.0 - position;
903            let reverse_b = -b[1] + (b[1] - b[0]) * distance;
904            let remaining = integral_constant_jerk_interval(distance, 0.0, -b[1], reverse_b);
905            assert_close(remaining, total - time, 2.0e-13);
906        }
907        Ok(())
908    }
909
910    #[test]
911    fn profile_time_preserves_stationary_divergence_and_simple_zeros() {
912        let s = [0.0, 1.0];
913        for (a, b) in [([0.0, 1.0], [0.0, 1.0]), ([1.0, 0.0], [-1.0, 0.0])] {
914            assert!(s_to_t_topp3(&s, (&a, &b, (0, 0)), 0.0).is_err());
915        }
916        let (total, _) = s_to_t_topp3(&[0.0, 2.0], (&[0.0, 0.0], &[1.0, -1.0], (0, 0)), 0.0)
917            .expect("two simple zeros have finite time");
918        assert_close(total, std::f64::consts::PI, 4.0 * f64::EPSILON);
919    }
920
921    #[test]
922    fn profile_anchor_does_not_retry_the_easier_soc_direction() {
923        // An inconsistent input must not get accepted by testing both sides
924        // and choosing whichever makes the time finite.
925        assert!(integral_constant_jerk_interval(1.0, 1.0, -1.0, 1.0).is_finite());
926        assert!(s_to_t_topp3(&[0.0, 1.0], (&[1.0, 0.25], &[-1.0, 1.0], (0, 0)), 0.0).is_err());
927    }
928
929    #[test]
930    fn natural_interval_time_handles_zero_curvature_and_two_simple_zeros() {
931        assert_close(
932            integral_constant_jerk_interval(3.0, 4.0, 0.0, 0.0),
933            1.5,
934            4.0 * f64::EPSILON,
935        );
936        assert_close(
937            integral_constant_jerk_interval(2.0, 0.0, 1.0, -1.0),
938            std::f64::consts::PI,
939            4.0 * f64::EPSILON,
940        );
941    }
942
943    #[test]
944    fn natural_interval_time_does_not_turn_an_exact_double_root_into_a_finite_time() {
945        // The ideal curvature is 1/3, which is not binary64. Materializing
946        // RN((u-b)/h) first therefore defines a different quadratic. The
947        // natural data satisfy D=a*(u-b)-h*b*b=0 exactly and have an interior
948        // double root at x=3.
949        let (h, a, b, u) = (6.0, 3.0, -1.0, 1.0);
950        assert!(matches!(
951            classify_interval_a_nonnegative(h, a, b, u),
952            Some(IntervalANonnegativeClassification::InteriorZero)
953        ));
954        assert_eq!(integral_constant_jerk_interval(h, a, b, u), f64::INFINITY);
955        assert!(integral_constant_jerk_interval(h, a, b, u.next_up()).is_finite());
956        assert!(integral_constant_jerk_interval(h, a, b, u.next_down()).is_nan());
957
958        let s = [0.0, h];
959        let acceleration = [a, h.mul_add(u, h.mul_add(b, a))];
960        let slope = [b, u];
961        assert!(s_to_t_topp3(&s, (&acceleration, &slope, (0, 0)), 0.0).is_err());
962    }
963
964    #[test]
965    fn natural_time_and_inverse_round_trip_for_all_curvature_signs() -> Result<(), CoppError> {
966        let cases = [
967            (0.5, 1.0, -0.1, 0.4),
968            (0.5, 1.0, 0.2, 0.2),
969            (0.5, 1.0, 0.5, -0.2),
970        ];
971        for (h, a, b, u) in cases {
972            let a_next = h.mul_add(u, h.mul_add(b, a));
973            let s = [0.0, h];
974            let acceleration = [a, a_next];
975            let slope = [b, u];
976            let (t_final, t_s) = s_to_t_topp3(&s, (&acceleration, &slope, (0, 0)), 0.0)?;
977            assert!(t_final.is_finite() && t_final > 0.0);
978
979            let query = [0.0, 0.25 * t_final, 0.5 * t_final, 0.75 * t_final, t_final];
980            let sampled = t_to_s_topp3(
981                &s,
982                (&acceleration, &slope, (0, 0)),
983                &t_s,
984                InterpolationMode::NonUniformTimeGrid(&query),
985            )?;
986            assert_eq!(sampled.len(), query.len());
987            assert_eq!(sampled[0], 0.0);
988            assert_eq!(sampled[query.len() - 1], h);
989            for pair in sampled.windows(2) {
990                assert!(pair[0] < pair[1], "non-monotone inverse: {sampled:?}");
991            }
992
993            for (&time, &x) in query[1..query.len() - 1]
994                .iter()
995                .zip(&sampled[1..sampled.len() - 1])
996            {
997                let b_at_x = ((u - b) / h).mul_add(x, b);
998                let reconstructed = integral_constant_jerk_interval(x, a, b, b_at_x);
999                assert_close(reconstructed, time, 2.0e-13);
1000            }
1001        }
1002        Ok(())
1003    }
1004
1005    #[test]
1006    fn stationary_head_and_tail_convention_is_unchanged() -> Result<(), CoppError> {
1007        let s = [0.0, 1.0, 2.0, 3.0];
1008        let a = [0.0, 1.0, 1.0, 0.0];
1009        let b = [0.0, 0.0, 0.0, 0.0];
1010        let (t_final, t_s) = s_to_t_topp3(&s, (&a, &b, (1, 1)), 0.0)?;
1011        assert_eq!(t_final, 7.0);
1012        assert_eq!(t_s, [0.0, 3.0, 4.0, 7.0]);
1013
1014        let query = [0.0, 3.0, 3.5, 4.0, 7.0];
1015        let sampled = t_to_s_topp3(
1016            &s,
1017            (&a, &b, (1, 1)),
1018            &t_s,
1019            InterpolationMode::NonUniformTimeGrid(&query),
1020        )?;
1021        assert_eq!(sampled, [0.0, 1.0, 1.5, 2.0, 3.0]);
1022        Ok(())
1023    }
1024}